/*
 * Copyright 2008 M-Way Solutions
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.mwaysolutions.ocm.impl;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Map.Entry;

import javax.jcr.Item;
import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.Property;
import javax.jcr.PropertyIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.Value;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springmodules.jcr.JcrUtils;

import com.mwaysolutions.ocm.OcmException;
import com.mwaysolutions.ocm.OcmPropertyDescriptor;
import com.mwaysolutions.ocm.OcmTypeDescriptor;
import com.mwaysolutions.ocm.OcmTypeRegister;
import com.mwaysolutions.ocm.OcmWriter;
import com.mwaysolutions.ocm.UniqueConstraintOcmException;

/**
 * OCM Writer Implementation
 *
 * @author <a href="mailto:m.zdila@mwaysolutions.com">Martin Ždila</a>
 */
public class OcmWriterImpl implements OcmWriter {

	private final static Log log = LogFactory.getLog(OcmWriter.class);

	private final Session session;
	private final Map<Object, Node> objectToNodeMap;

	private final OcmTypeRegister ocmTypeRegister;
	private final List<DelayedReference> delayedReferenceList = new ArrayList<DelayedReference>();



	static abstract class DelayedReference {
		protected Node node;
		protected String property; // property name

		public abstract void connect() throws RepositoryException;
	}


	static class DelayedReferenceArray extends DelayedReference {
		protected Object[] refObjects; // alt A
		protected String[] refUuidPropertyNames; // alt A
		protected Node[] nodes; // alt B

		@Override
		public void connect() throws RepositoryException {
			final Value[] values = new Value[refObjects.length];
			for (int i = 0; i < refObjects.length; i++) {
				final Session session = node.getSession();

				if (nodes[i] != null) {
					values[i] = session.getValueFactory().createValue(nodes[i]);
				} else {
					final String refUuid = (String) OcmUtils.getProperty(refObjects[i], refUuidPropertyNames[i]);
					if (refUuid == null) {
						throw new NullPointerException(refUuidPropertyNames[i] + " is null. Have you saved the referenced object already?");
					}
					values[i] = session.getValueFactory().createValue(session.getNodeByUUID(refUuid));
				}

			}

			node.setProperty(property, values);
		}

	}


	static class DelayedReferenceSimple extends DelayedReference {
		protected Object refObject;
		protected String refUuidPropertyName;

		@Override
		public void connect() throws RepositoryException {
			final String refUuid = (String) OcmUtils.getProperty(refObject, refUuidPropertyName);
			if (refUuid == null) {
				throw new NullPointerException(refUuidPropertyName + " is null. Have you saved the referenced object already?");
			}
			node.setProperty(property, node.getSession().getNodeByUUID(refUuid));
		}
	}





	public OcmWriterImpl(final OcmTypeRegister ocmTypeRegister, final Session session) {
		this(ocmTypeRegister, session, new IdentityHashMap<Object, Node>());
	}


	/**
	 *
	 * @param session
	 * @param objectToNodeMap Map for mapping saved objects to Nodes. It is user for references to parent (preventing loops).
	 */
	public OcmWriterImpl(final OcmTypeRegister ocmTypeRegister, final Session session, final Map<Object, Node> objectToNodeMap) {
		this.ocmTypeRegister = ocmTypeRegister;
		this.session = session;
		this.objectToNodeMap = objectToNodeMap;
	}


	/* (non-Javadoc)
	 * @see com.mwaysolutions.ocm.impl.OcmWriter#saveOrUpdate(javax.jcr.Node, java.lang.String, java.lang.Object, boolean, boolean)
	 */
	public Item saveOrUpdate(final Node node, final String jcrPropertyName, final Object value, final boolean asReference, final boolean nonRefAsNew) throws OcmException, RepositoryException {
		if (value == null) { // null
//			if (!jcrPropertyName.equals("jcr:uuid") /* not protected */) {
				OcmUtils.removeAllNodes(node, jcrPropertyName);
				OcmUtils.removeProperty(node, jcrPropertyName);
//			}
			return null;
		}


		if (ocmTypeRegister.getTypeDescriptorByClass(value.getClass(), false) != null) { // registered: class / array of classes / collection / map

			if (value.getClass().isArray() || value instanceof Collection) { // array / collection
				if (asReference && value.getClass().isArray()) { // array of references
					setReference(node, jcrPropertyName, value);
					return null;
				}

				// array/collection of registered types

				final Node wrapperNode = getOrCreateNode(node, jcrPropertyName, value, nonRefAsNew);
				final Collection<?> iterable = value.getClass().isArray() ? Arrays.asList((Object[]) value) : (Collection<?>) value;

				if (!wrapperNode.isNew()) {
					final Map<String, Set<Object>> uniqueSetMap = new HashMap<String, Set<Object>>();
					final Set<String> uuidSet = new HashSet<String>();
					// collect all uuids and unique fields from the collection
					for (final Object item : iterable) {
						if (item == null) {
							continue;
						}

						final String uuid = getUuid(item);
						if (uuid != null) {
							uuidSet.add(uuid);
						}

						// find unique fields
						for (final OcmPropertyDescriptor ocmPropertyDescriptor : ocmTypeRegister.getTypeDescriptorByClass(item.getClass(), true)) {
							if (ocmPropertyDescriptor.isUnique()) {
								final String jcrPropertyName2 = ocmPropertyDescriptor.getJcrPropertyName();
								Set<Object> uniqueSet = uniqueSetMap.get(jcrPropertyName2);
								if (uniqueSet == null) {
									uniqueSet = new HashSet<Object>();
								}
								uniqueSet.add(OcmUtils.getProperty(item, ocmPropertyDescriptor.getBeanPropertyName()));
								uniqueSetMap.put(jcrPropertyName2, uniqueSet);
							}
						}
					}

					// remove all nodes not in collection
					for (final NodeIterator ni = wrapperNode.getNodes(); ni.hasNext(); ) {
						final Node itemNode = ni.nextNode();

						remove: {
							if (itemNode.isNodeType("mix:referenceable") && uuidSet.contains(itemNode.getUUID())) {
								break remove;
							}

							for (final Entry<String, Set<Object>> entry : uniqueSetMap.entrySet()) { // TODO: implement also for list/map
								final Object uniqValue = OcmUtils.getValue(itemNode.getProperty(entry.getKey()));
								if (entry.getValue().contains(uniqValue)) {
									break remove;
								}
							}

							itemNode.remove();
						}

					}
				}

				for (final Object item : iterable) {
					if (item == null) {
						log.warn("collection/array " + jcrPropertyName + " contains unsupported null value; skipping");
					} else {
						saveOrUpdate(wrapperNode, COLLECTION_ITEM, item, asReference, true);
					}
				}

				return wrapperNode;
			} else if (value instanceof Map) { // map
				final Node mapWrapperNode = getOrCreateNode(node, jcrPropertyName, value, nonRefAsNew);
				final Map<?, ?> map = (Map<?, ?>) value;

				if (!mapWrapperNode.isNew()) {
					// remove unused keys
					for (final PropertyIterator pi = mapWrapperNode.getProperties(); pi.hasNext(); ) {
						final Property property = pi.nextProperty();
						if (map.get(property.getName()) == null && !property.getName().startsWith("jcr:")) {
							property.remove();
						}
					}

					for (final NodeIterator ni = mapWrapperNode.getNodes(); ni.hasNext(); ) {
						final Node n = ni.nextNode();
						final Object mapValue = map.get(n.getName());

						if (mapValue == null) {
							n.remove();
						} else if (n.hasProperty("jcr:uuid")) {
							// also remove node if it is referenceable, but UUIDs is null or not equal
							final String uuidBeanPropertyName = getUuidBeanPropertyName(mapValue);
							if (uuidBeanPropertyName != null && !n.getUUID().equals(OcmUtils.getProperty(mapValue, uuidBeanPropertyName))) {
								n.remove();
							}
						}
					}
				}

				for (final Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
					final Object mapValue = entry.getValue();
					final Object mapKey = entry.getKey();
					if (mapValue == null) {
						log.warn("map " + jcrPropertyName + " contains unsupported null value, skipping");
					} else if (mapKey == null || !(mapKey instanceof String)) {
						log.warn("map " + jcrPropertyName + " contains unsupported null or non java.lang.String key");
					} else {
						saveOrUpdate(mapWrapperNode, JcrUtils.encode((String) mapKey), mapValue, asReference, false);
					}
				}

				return mapWrapperNode;
			} else { // registered class
				if (asReference) {
					setReference(node, jcrPropertyName, value);
//					return node.setProperty(jcrPropertyName, session.getNodeByUUID(getUuid(value)));
				} else {
					final Node node2 = getOrCreateNode(node, jcrPropertyName, value, nonRefAsNew);
					saveOrUpdate(node2, value);
					return node2;
				}
			}
		} else if (value.getClass().isArray() && !(value instanceof byte[])) { // should be primitive array (excluding byte array)
			final Value[] values = new Value[Array.getLength(value)];

			for (int i = 0, l = Array.getLength(value); i < l; i++) {
				final Object item = Array.get(value, i);
				if (item == null) { // skip unsupported nulls
					log.warn("array " + jcrPropertyName + " contains unsupported null value, skipping");
				} else {
					values[i] = OcmUtils.getValue(item, session.getValueFactory());
				}
			}

			// FourPee fix if value stored in jcr has not a isMultiple definition - causes exception during save
			if (node.hasProperty(jcrPropertyName) && !node.getProperty(jcrPropertyName).getDefinition().isMultiple()) {
				OcmUtils.removeProperty(node, jcrPropertyName);
			}

			return node.setProperty(jcrPropertyName, values);
		} else if (!jcrPropertyName.equals("jcr:uuid") /* TODO: test for RO */) { // should be jcr-primitive
			if (asReference) { // TODO: support array of references
//				return node.setProperty(jcrPropertyName, session.getNodeByUUID((String) value)); // value should be string uuid
				setReference(node, jcrPropertyName, value);
				return null;
			}

			// FourPee fix if value stored in jcr has a isMultiple definition - causes exception during save
			if (node.hasProperty(jcrPropertyName) && node.getProperty(jcrPropertyName).getDefinition().isMultiple()) {
				OcmUtils.removeProperty(node, jcrPropertyName);
			}

			return node.setProperty(jcrPropertyName, OcmUtils.getValue(value, session.getValueFactory()));
		}

		return null;
	}


	/* (non-Javadoc)
	 * @see com.mwaysolutions.ocm.impl.OcmWriter#saveOrUpdate(javax.jcr.Node, java.lang.Object)
	 */
	public void saveOrUpdate(final Node node, final Object value) throws OcmException, RepositoryException {
		for (final OcmPropertyDescriptor ocmPropertyDescriptor : ocmTypeRegister.getTypeDescriptorByClass(value.getClass(), true)) {
			final Object propertyValue = OcmUtils.getProperty(value, ocmPropertyDescriptor.getBeanPropertyName());

			// now if value has uuid and this uuid is null (new item) then remove all nodes with the same name
			// this is for elimination duplicate node creation
			if (propertyValue != null) {
				final OcmTypeDescriptor propertyTypeDescriptor = ocmTypeRegister.getTypeDescriptorByClass(propertyValue.getClass(), false);
				if (propertyTypeDescriptor != null) { // is registered ocm type
					final OcmPropertyDescriptor uuidPropertyDescriptor = propertyTypeDescriptor.getUuidPropertyDescriptor();
					if (uuidPropertyDescriptor != null) {
						final String propertyUuid = (String) OcmUtils.getProperty(propertyValue, uuidPropertyDescriptor.getBeanPropertyName());
						if (propertyUuid == null) {
							OcmUtils.removeAllNodes(node, ocmPropertyDescriptor.getJcrPropertyName());
						}
					}
				}
			}

			saveOrUpdate(node, ocmPropertyDescriptor.getJcrPropertyName(), propertyValue, ocmPropertyDescriptor.isReference(), false);
		}
	}


	private Node getOrCreateNode(final Node parentNode, final String jcrPropertyName, final Object value, final boolean nonRefAsNew) throws RepositoryException {
		final OcmTypeDescriptor typeDescriptor = ocmTypeRegister.getTypeDescriptorByClass(value.getClass(), true);
		final OcmPropertyDescriptor uuidPropertyDescriptor = typeDescriptor.getUuidPropertyDescriptor();
		final String uuidPropertyName = uuidPropertyDescriptor != null ? uuidPropertyDescriptor.getBeanPropertyName() : null;
		final String uuid = uuidPropertyName == null ? null : (String) OcmUtils.getProperty(value, uuidPropertyName);

		Node node = null;

		final Node uniqNode = findNodeByUnique(parentNode, value);

		if (uuidPropertyName == null) {
			node = uniqNode;
			if (node == null && !nonRefAsNew) {	// so that collection's ocm:item is not reused for each item
				node = parentNode.hasNode(jcrPropertyName) ? parentNode.getNode(jcrPropertyName) : null;
			}
		} else if (uuid != null) {
			node = session.getNodeByUUID(uuid);

			// check for unique
			if (uniqNode != null && !uniqNode.isSame(node)) {
				throw new UniqueConstraintOcmException(uniqNode);
			}
		} else if (uniqNode != null) {
			throw new UniqueConstraintOcmException(uniqNode);
		}


		// remove node if it's type doesn't match value type
		if (node != null && ocmTypeRegister.getTypeDescriptorByNodeTypeName(node.getPrimaryNodeType().getName(), true).getClazz() != value.getClass()) {
			node.remove();
			node = null;
		}

		if (node == null) {
			node = parentNode.addNode(jcrPropertyName, typeDescriptor.getNodeTypeName());
			if (uuidPropertyName != null) {
				OcmUtils.setProperty(value, uuidPropertyName, node.getUUID());
			}
		}

		objectToNodeMap.put(value, node);

		return node;
	}


	private Node findNodeByUnique(final Node node, final Object value) throws RepositoryException {
		final OcmTypeDescriptor ocmTypeDescriptorByClass = ocmTypeRegister.getTypeDescriptorByClass(value.getClass(), true);

		// process uniques; TODO improve speed by skipping if there is no unique property defined
		for (final NodeIterator ni = node.getNodes(); ni.hasNext(); ) {
			final Node subNode = ni.nextNode();

			final OcmTypeDescriptor ocmTypeDescriptorByType = ocmTypeRegister.getTypeDescriptorByNodeTypeName(subNode.getPrimaryNodeType().getName(), false);
			if (ocmTypeDescriptorByClass != ocmTypeDescriptorByType) { // continue if not the same class
				continue;
			}

			for (final OcmPropertyDescriptor ocmPropertyDescriptor : ocmTypeDescriptorByType) {
				if (!ocmPropertyDescriptor.isUnique()) {
					continue;
				}

				final Object jcrUniqValue = OcmUtils.getValue(subNode.getProperty(ocmPropertyDescriptor.getJcrPropertyName()));
				final Object javaUniqValue = OcmUtils.getProperty(value, ocmPropertyDescriptor.getBeanPropertyName());
				if (jcrUniqValue.equals(javaUniqValue)) {
					return subNode;
				}
			}
		}

		return null;
	}


	private String getUuidBeanPropertyName(final Object value) {
		final OcmPropertyDescriptor uuidPropertyDescriptor = ocmTypeRegister.getTypeDescriptorByClass(value.getClass(), true).getUuidPropertyDescriptor();
		return uuidPropertyDescriptor == null ? null : uuidPropertyDescriptor.getBeanPropertyName();
	}


	private String getUuid(final Object value) {
		final String uuidBeanPropertyName = getUuidBeanPropertyName(value);
		return uuidBeanPropertyName == null ? null : (String) OcmUtils.getProperty(value, uuidBeanPropertyName);
	}


	/* (non-Javadoc)
	 * @see com.mwaysolutions.ocm.impl.OcmWriter#saveDelayedReferences()
	 */
	public void saveDelayedReferences() throws RepositoryException {
		// setup node references
		// this is delayed because of possible references to not-yet-saved nodes
		for (final DelayedReference delayedReference : delayedReferenceList) {
			delayedReference.connect();
		}
	}


	private void setReference(final Node node, final String property, final Object value) throws RepositoryException {
		if (value == null) {
			node.setProperty(property, (Value) null); // TODO: check for arrays!
		} else if (value.getClass().isArray()) {
			final int len = Array.getLength(value);
			final DelayedReferenceArray delayedReference = new DelayedReferenceArray();
			delayedReference.node = node;
			delayedReference.property = property;
			delayedReference.nodes = new Node[len];
			delayedReference.refObjects = new Object[len];
			delayedReference.refUuidPropertyNames = new String[len];

			for (int i = 0; i < len; i++) {
				final Object item = Array.get(value, i);

				if (objectToNodeMap.containsKey(item)) {
					delayedReference.nodes[i] = objectToNodeMap.get(item);
				} else {
					final String uuidPropertyName = getUuidBeanPropertyName(item);
					if (uuidPropertyName == null) {
						throw new OcmException("Referenced bean is not Referenceabe: " + item.getClass().getName());
					}
					final String refUuid = (String) OcmUtils.getProperty(item, uuidPropertyName);
					if (refUuid == null) {
						delayedReference.refObjects[i] = item;
						delayedReference.refUuidPropertyNames[i] = uuidPropertyName;
					} else {
						delayedReference.nodes[i] = session.getNodeByUUID(refUuid);
					}
				}
			}

			delayedReferenceList.add(delayedReference);
		} else if (objectToNodeMap.containsKey(value)) { // setting reference to working node (preventing loops)
			node.setProperty(property, objectToNodeMap.get(value));
		} else if (value instanceof String) { // reference is String UUID
			// TODO: make also delayed
			node.setProperty(property, session.getNodeByUUID((String) value));
		} else { // setting reference to not working node
			final String uuidPropertyName = getUuidBeanPropertyName(value);
			if (uuidPropertyName == null) {
				throw new OcmException("Referenced bean has no UUID property: " + value.getClass().getName());
			}
			final String refUuid = (String) OcmUtils.getProperty(value, uuidPropertyName);
			if (refUuid == null) {
				final DelayedReferenceSimple delayedReference = new DelayedReferenceSimple();
				delayedReference.node = node;
				delayedReference.property = property;
				delayedReference.refObject = value;
				delayedReference.refUuidPropertyName = uuidPropertyName;
				delayedReferenceList.add(delayedReference);
			} else {
				node.setProperty(property, session.getNodeByUUID(refUuid));
			}
		}

	}

}
